fix(statistical): MAX/MIN/MAXA/MINA — an empty argument is an error, a numberless one is zero - #777
Merged
Merged
Conversation
`=MIN({})` answered 0 while `=MAX({})` answered #REF!, so the two
reducers disagreed about whether the same empty input is an error. A
plausible-looking 0 propagates silently into whatever consumes it; an
error is visible.
`min_fn` now returns #REF! when an argument is an empty array, the same
rule `max_fn` already carries.
The rule is deliberately narrower than MAX's. `max_fn` errors whenever it
saw an array and found no numbers at all; MIN cannot, because the
fixtures pin `=IFERROR(MIN({"a","b","c"}),"no numbers")` to the number 0.
So a *populated* array holding nothing numeric still answers 0, and only
a genuinely empty one is #REF!.
Verified with a differential over 14,200 cases (MIN, MAX, MINA, MAXA
across scalars, numeric/text/boolean/mixed/blank arrays, empty strings,
numeric text, nested and 2-D shapes, leading and trailing errors, zoned
instants, sparklines, and range- and name-delivered forms through a
seeded resolver, as 1-, 2- and 3-argument calls). 554 cases changed;
every one is a MIN call carrying an empty-array argument and every one
now returns #REF!. MAX, MINA and MAXA are byte-identical.
The `=MIN(SPARKLINE({1,2,3}),{})` row in bugs.tsv now passes (2055 →
2056 passing, 182 → 181 open). It is left in place: relocating it to its
category TSV is a fixtures-only change and cannot ride in a commit that
also touches code.
closes #771
…d MAXA
Google Sheets was asked directly about all four reducers, and the rule is
uniform:
{} {"a","b"} blank range
MAX #REF! 0 #REF!
MIN #REF! 0 #REF!
MAXA #REF! 0 —
MINA #REF! 0 —
So "no numbers" is two conditions, not one. An *absent* argument is
#REF!; a *populated* argument that happens to hold nothing numeric is 0.
A range of blank cells sides with the empty array rather than the text
array — blanks are absent, not present-and-unusable.
MAX collapsed both into a single `had_array && result.is_none()` check
and so answered #REF! for `=MAX({"a","b"})` and `=MAX({TRUE,FALSE})`,
which are 0. It now also tracks whether an array held anything other than
a blank, and errors only when it did not — leaving `=MAX(<blank range>)`
at #REF! where it already belonged.
MIN gained the blank-array half of the same rule, which its previous
empty-array-only fix did not cover.
MINA and MAXA needed the empty-array check alone. They reach the other
answers by a different mechanism — text folds in as 0 rather than being
skipped, so `=MAXA({"a","b",5,3})` is 5 and `=MAXA({"a","b",-5,-3})` is
0 — and a populated array is therefore never numberless for them. Their
agreement with MAX on the two probed rows is a coincidence of values, not
shared semantics, so they keep their own fold helpers.
The blank-range answer for MINA and MAXA has no captured row, so both
keep today's #N/A there rather than being moved to match their non-A
counterparts on a guess.
Verified with a differential over 16,320 cases across all four functions
(scalars, numeric/text/boolean/blank/mixed arrays, empty strings, numeric
text, nested and 2-D shapes, flat and nested-row blank ranges, leading
and trailing errors, zoned instants, sparklines, and range- and
name-delivered forms through a seeded resolver, as 1-, 2- and 3-argument
calls). 1,879 cases changed:
MIN 624 → #REF!, every one carrying an absent or numberless argument
MAX 199 #REF! → 0, every one carrying a numberless argument, and no
case whose arguments are all absent moved at all
MINA 528 → #REF!, every one carrying an empty array
MAXA 528 → #REF!, every one carrying an empty array
No case changed in any other direction, and none changed without a
triggering argument. Every captured row above is reproduced, including
the two that must not move: `=MAX(<blank range>)` stays #REF! and
`=MAX(SPARKLINE({1,2,3}),{})` stays #REF!.
bugs.tsv is unchanged at 2056 passing / 181 open — the same single row
that this branch already flipped, with no row regressing.
The zone-aware short-circuit is untouched: `=MIN(TZDATETIME(...),{})`
still returns the instant rather than #REF!, because `zoned_extreme` runs
before the argument loop. No captured row covers it.
… one
The blank-range evidence behind the previous commit was a probe artifact,
not a Sheets answer. Controls added alongside it show the harness never
resolved `Data!` ranges for these functions at all:
=COUNTA(Data!M1:M4) 1 the sheet exists and M4 holds 1
=MAX(Data!M4) #REF! but MAX cannot see it
=MAX(Data!M1:M3) #REF! so this was never about blanks
MAX over a single cell holding 1 cannot really be #REF!, so every `Data!`
row was measuring the harness. Those rows have been withdrawn from the
capture, which now contains only array literals — which need no setup:
{} #REF! MAX, MIN, MAXA, MINA
{"a","b"} 0 all four
{TRUE,FALSE} 0 MAX
Both oracle-backed halves stay. What goes is the claim about blanks.
MIN loses the `array_had_content` rule entirely; its empty-array check is
enough, because a populated-but-numberless array already answered 0. An
all-blank array is back to 0.
MAX keeps `array_had_content`, but only as the thing that carves text and
booleans *out* of the old blanket rule. An all-blank array sets no
content and so keeps the #REF! MAX has always given it. That is not a
claim about blanks — it is the absence of one.
MINA and MAXA were never touched on this axis and stay at #N/A.
Blank-only arrays are therefore unprobed for all four, and the four do
not agree with each other:
MAX #REF! MIN 0 MAXA #N/A MINA #N/A
That disagreement predates this branch and is left exactly as found. It
needs a capture, not a guess.
Differential re-run over 16,320 cases against origin/main. 1,829 changed:
MIN 574 → #REF!, every one carrying an empty array
MAX 199 #REF! → 0, every one carrying a numberless array
MINA 528 → #REF!, every one carrying an empty array
MAXA 528 → #REF!, every one carrying an empty array
Machine-checked assertions, all clean. The one that matters most here:
688 cases have a blank-only array as their only array argument, and
**zero** of them changed. Blank-only shapes were probed flat, as nested
one-element rows, and via a defined name, for all four functions; every
one is byte-identical to main.
bugs.tsv unchanged at 2056 passing / 181 open, with the failing-row sets
diffed rather than the counts compared: one row flipped to passing, none
regressed.
Review blocker. `array_had_content` was set by a catch-all `_ => true`, so
every non-numeric variant inherited content-hood — including `Value::Date`,
which `max_array_into` does not fold into a result.
=MAX({DATE(2020,1,1),DATE(2021,1,1)}) main #REF! branch 0
=MAX(<a column of dates>) main #REF! branch 0
A workbook date cell maps to `Value::Date`, so `=MAX(A1:A10)` over a date
column is the everyday form of this. Both answers are wrong against
Sheets, which returns the latest date — but turning a visible error into a
plausible-looking 0 is precisely the hazard this branch exists to remove,
and it did so in the one shape no fixture covers.
The flag now matches `Value::Text(_) | Value::Bool(_)` explicitly, which is
exactly what the capture backs. Everything else numberless — dates, zoned
instants, and any variant added later — keeps whatever main answered. A
regression test pins the date case so it cannot drift again.
Also in this commit, all from the same review:
- Dropped the dead `had_content` write in the `Sparkline` arm. The
`skipped_sparkline` early return always precedes the `had_content` test,
so the write could never be read; the doc comment described an effect
that did not exist. With it gone the sparkline flag is load-bearing
again, as originally designed.
- Removed a self-confirmed assertion from tests/sparkline.rs, whose header
promises every expectation is a captured row. `=MIN(SPARKLINE({1,2,3}),
{"a"})` was not one. The remaining MIN assertion there cites its
bugs.tsv row.
- Corrected min/mod.rs, which claimed the fixtures pin `=MIN({"a","b"})`.
They do not — only the IFERROR row exists in-repo. The doc now says which
evidence is in this repo and which lands separately.
- Formatted the five new assertions rustfmt objected to. Pre-existing
violations left alone.
Nested empty arrays (`{{}}`) are left as found: MAX #REF!, MIN 0, MAXA and
MINA #N/A. Unreachable from a resolver-delivered range, unprobed for all
four, and the same open question as blank-only arrays — one capture would
settle both. Making three of them agree with MAX on a guess is the move
that produced the original defect.
The date behaviour is filed separately as #776 rather than fixed here; it
needs an oracle this repo does not have.
Differential re-run over 20,128 cases against origin/main, now with date
atoms in six materializations. 2,077 changed:
MIN 606 → #REF!, every one carrying an empty array
MAX 351 #REF! → 0, every one carrying a numberless array
MINA 560 → #REF!, every one carrying an empty array
MAXA 560 → #REF!, every one carrying an empty array
Machine-checked, no violations:
date-only arguments 920 cases, 0 changed
blank-only arguments 720 cases, 0 changed
All six date shapes — inline literal, cell range, named array, nested
rows, date beside a blank, date beside a number — are identical to main
for all four functions. Every oracle row still holds.
Contributor
Test Coverage by Category
✓ = 100% passing · ⚠ = known deviation · The ~79,437 total counts formula evaluations (each conformance row and each property case = 1). GitHub Checks reports 3,745 Rust test functions: 2,910 unit + 159 property functions (shown as cases above) + 676 conformance/integration. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
closes #771
Summary
MINreturned0for an empty array whereMAXand Google Sheets both return#REF!. Probing that turned it into a four-function problem with a rule none of them fully implemented."No numbers" is two conditions, not one:
{}{"a","b"}#REF!0MAXbefore#REF!✓#REF!✗MINbefore0✗0✓MAXA/MINAbefore#N/A✗0✓An empty argument is an error. A populated argument that happens to hold nothing numeric is
0.MAXcollapsed both into one check and was wrong in one direction; the other three skipped the check and were wrong in the other.Booleans do not coerce inside an array, so
{TRUE,FALSE}is numberless the same way{"a","b"}is — captured, and easy to miss.The review caught this PR inverting its own premise
The first version marked "this array had content" with a catch-all
_ => had_content = true. That caught text and booleans — intended — and alsoValue::Date:recalc.rsmaps a workbook date cell toCoreValue::Date, so that is the everyday form. #771 exists because a silent0is more dangerous than a visible error, and the fix would have manufactured one on a different input — in the one shape no fixture covers.Now explicit:
Carves out exactly what the oracle backs, and stops every future
Valuevariant inheriting content-hood by accident. Pinned bymax_array_of_only_dates_is_unchanged_at_ref_error.That defect exposed a real pre-existing one —
MAX/MINignore dates in arrays entirely, so=MIN(A1:A10)over a date column has always returned a silent zero. Filed as #776, deliberately not fixed here.Differential — 20,128 cases
Two builds, this branch versus a clean export of
origin/main, machine-checked assertions rather than eyeballing:#REF!#REF!→0(MAXonly)MAXmoved only#REF!→0, never the reverse. Nothing changed without a triggering argument. Fourteen other reducers —SUM,AVERAGE,COUNT,PRODUCT,MEDIAN,STDEVand friends — byte-identical.Blank-only arrays are deliberately untouched. The four disagree three ways there (
MAX#REF!,MIN0,MAXA/MINA#N/A); it is unprobed and tracked as #775. An earlier attempt to probe it produced a harness artifact — the sheet reference did not resolve, so=MAX(Data!M4)returned#REF!withM4holding1— and that evidence was withdrawn rather than shipped. Nested-empty ({{}}) is left for the same reason and by the same argument: it is the same question, and one capture settles both.Only
=MAX({})→#REF!has an in-repo fixture row.=MIN({}),=MAXA({}),=MINA({}),=MAX({"a","b"}),=MAX({TRUE,FALSE})and the text-array rows for the other three are captured only in the conformance-fixtures pipeline, not yet exported here.A reviewer working from this repo alone cannot verify them. The ordering is forced — those rows fail until this code exists — so they land in a fixtures-only PR immediately after, which will also relocate
bugs.tsvrow 2239 (=MIN(SPARKLINE({1,2,3}),{})→#REF!, passing as of this branch).How to test
Directly:
Review
Test plan
cargo test --workspace— 3,751 tests, 0 failurescargo clippy --workspace -- -D warnings— cleancargo nextest run --workspace --profile ci— exit 0bugs.tsvfailing-row sets diffed, not counts — one row flipped to passing, zero regressedmod.rsfiles rustfmt-clean (mainis dirty repo-wide; no new violations added)Related
MAX/MINignore dates in arrays, found by the review🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.